iT邦幫忙

2026 iThome 鐵人賽

DAY 30
0
JavaScript

JavaScript為什麼筆記本系列 第 30 篇

Day 30|async/await 看起來像同步,底下為什麼還是非同步?

  • 分享至 

  • xImage
  •  

第30天了。好想來曬娃xd
這段日子真的是累得半死,沒錯!我發現挪時間讀書真的是累到爆,不開玩笑,特別是兒子生病、自己生病、半夜泡奶、有人沒睡過夜...暈
但某方面來說,這又像是另一個睡前的動力。謝謝一路看到這邊的各位。你們也很不簡單!(鞠躬

Day 29 最後留下這段:

async function loadData() {
  const response = await fetch('/api/data')
  return response.json()
}

它讀起來很像:

先執行 fetch
↓
停在 await
↓
資料回來
↓
再往下跑

以前我看到 await,會很自然把它理解成:

JavaScript 在這一行停下來等。

但如果主執行緒真的整個停住,網頁不就不能處理點擊、不能更新畫面,其他程式也不能跑?

所以最後一天想把前面幾天全部收回來,只問一個問題:

async/await 明明寫得像同步流程,為什麼 JavaScript 主執行緒沒有被卡住?

先看最小例子

async function run() {
  console.log('A')

  await Promise.resolve()

  console.log('B')
}

run()

console.log('C')

如果 await 是「整個 JavaScript 停住」,我可能會猜:

A
B
C

但一般情況下會得到:

A
C
B

這個順序其實已經把答案露出來了。

run() 開始
↓
印 A
↓
遇到 await
↓
run() 暫時交還控制權
↓
外面繼續跑
↓
印 C
↓
之後再回到 run()
↓
印 B

所以 await 暫停的不是整個 JavaScript 世界。

比較接近:

它暫停的是目前這個 async function 後面的執行,讓主執行緒先去做其他工作。

async function 其實會回傳 Promise

先看:

async function getNumber() {
  return 42
}

const result = getNumber()

console.log(result)

result 不是直接的 42。

它是一個 Promise。

概念上可以先想成:

async function
↓
一定用 Promise 表示它未來的完成結果

所以:

getNumber().then(value => {
  console.log(value)
})

最後會拿到:

42

這很重要,因為 async/await 並不是另一套跟 Promise 無關的非同步系統。

它其實是建立在 Promise 心智模型上。

await 到底在等什麼?

看:

async function run() {
  const value = await Promise.resolve(123)

  console.log(value)
}

await 後面會先取得一個值。

如果那是一個 Promise,就等待它完成。

完成之後:

Promise fulfilled
↓
async function 剩下的部分
↓
之後再被安排回來執行

所以可以先把:

const value = await somePromise
console.log(value)

粗略理解成:

somePromise.then(value => {
  console.log(value)
})

這不是要說兩種寫法在所有語意細節上都完全一樣。

而是建立心智模型:

await 後面的程式,本質上是在延續一個 Promise 完成後的流程。

那為什麼 B 在 C 後面?

回到:

async function run() {
  console.log('A')
  await Promise.resolve()
  console.log('B')
}

run()
console.log('C')

可以拆成:

目前 Task 開始
↓
呼叫 run()
↓
印 A
↓
遇到 await
↓
把 run() 後半段暫停
↓
回到外層
↓
印 C
↓
目前同步程式結束
↓
處理 Promise 相關 Microtask
↓
恢復 run()
↓
印 B

所以結果是:

A
C
B

這就直接接回 Day 29:

Promise 的後續工作會透過 Microtask 排程。

async/await 把這種流程寫得比較像一般從上往下的程式。

await 一個普通數字,也會晚一點嗎?

看這段:

async function test() {
  console.log('A')

  const value = await 123

  console.log(value)
  console.log('B')
}

test()

console.log('C')

很多人會想:

123 又不是 Promise,應該不用等吧?

但執行順序仍會是:

A
C
123
B

原因不是 123 真的需要花時間取得。

而是 await 會把後續執行切開。

可以先想成:

await 123
↓
把 123 當成已經完成的結果
↓
但 async function 後半段仍在之後的排程點繼續

所以 await 不只是在「網路真的很慢」時才產生非同步邊界。

那 fetch 又發生什麼?

現在回到真實前端:

async function loadUser() {
  const response = await fetch('/api/user')
  const user = await response.json()

  console.log(user)
}

第一個 await:

發出 fetch
↓
網路等待交給瀏覽器執行環境
↓
目前 async function 暫停
↓
JavaScript 可以先做別的事
↓
HTTP response 回來
↓
fetch 的 Promise 完成
↓
async function 有機會繼續

第二個 await:

response.json()
↓
回傳 Promise
↓
等待 Body 被讀取並解析
↓
完成後再繼續

所以它看起來像:

第一步做完
第二步做完
第三步做完

但底下其實仍然是一連串 Promise 與排程。

async/await 解決的不是「非同步」

非同步在 Promise、callback 時代就已經存在。

async/await 解決的比較像:

讓依序相依的非同步流程,更接近人閱讀同步程式的方式。

例如 Promise chain:

fetch('/api/user')
  .then(response => response.json())
  .then(user => {
    console.log(user)
  })
  .catch(error => {
    console.error(error)
  })

用 async/await:

async function loadUser() {
  try {
    const response = await fetch('/api/user')
    const user = await response.json()

    console.log(user)
  } catch (error) {
    console.error(error)
  }
}

底層仍然不是同步 I/O。

只是控制流程變得比較容易由上往下閱讀。

await 會不會卡住畫面?

正常等待 Promise 時,不會因為 await 本身卡住整個主執行緒。

真正會卡主執行緒的,是同步工作一直不結束。

例如:

console.log('start')

const start = Date.now()

while (Date.now() - start < 5000) {
  // 故意同步忙 5 秒
}

console.log('end')

這五秒裡:

Call Stack 一直被占著
↓
事件不能正常處理
↓
Microtask 也沒有機會執行
↓
畫面更新也會被拖住

這跟:

await fetch('/api/data')

完全是不同的事情。

await 是把控制權交回去。

busy loop 是死抓著主執行緒不放。

這就是「不阻塞」真正有感的地方

假設:

async function loadData() {
  console.log('開始抓資料')

  const response = await fetch('/api/data')

  console.log('資料回來了')

  return response.json()
}

loadData()

console.log('使用者還可以繼續操作其他流程')

流程比較像:

JavaScript
↓
發起網路要求
↓
loadData 暫停
↓
主執行緒繼續跑其他 JavaScript
↓
瀏覽器繼續處理事件
↓
未來 fetch 完成
↓
Promise reaction 進入排程
↓
loadData 繼續

這也是為什麼「等待網路」跟「CPU 忙到爆」不能混為一談。

多個 await 一定要一個一個等嗎?

這裡也是實務很常踩的地方。

假設兩個請求彼此沒有依賴:

const user = await fetch('/api/user')
const news = await fetch('/api/news')

這種寫法會先等第一個完成,才開始第二個。

如果兩個本來可以同時開始,就可能浪費時間。

可以改成:

const userPromise = fetch('/api/user')
const newsPromise = fetch('/api/news')

const [user, news] = await Promise.all([
  userPromise,
  newsPromise
])

現在變成:

user request 先發出去
news request 也立刻發出去
↓
兩個一起等待
↓
全部完成後再往下

所以 async/await 雖然好讀,還是要知道自己到底在排什麼。

不然很容易把可以平行等待的事情,寫成一條很長的瀑布。

error 為什麼可以用 try/catch?

Promise 寫法常看到:

doSomething()
  .then(...)
  .catch(error => {
    console.error(error)
  })

async/await 則可以寫:

async function run() {
  try {
    const result = await doSomething()
    console.log(result)
  } catch (error) {
    console.error(error)
  }
}

如果 await 的 Promise rejected:

Promise rejected
↓
await 這個位置像是丟出一個錯誤
↓
附近的 try/catch 可以接住

所以 async/await 讓非同步錯誤處理也比較接近一般同步控制流程。

但要記得:

async function 自己如果沒有把錯誤 catch 起來,它回傳的 Promise 就會 rejected。

最小實驗

在瀏覽器 Console 跑:

async function demo() {
  console.log('1')

  await Promise.resolve()

  console.log('2')

  await Promise.resolve()

  console.log('3')
}

console.log('A')

demo()

console.log('B')

先猜順序。

答案通常是:

A
1
B
2
3

第一個 await 讓 demo 暫時停下。

外層先印 B。

接著 demo 在 Microtask 中恢復,印 2。

又遇到第二個 await,再把後半段排到之後的 Microtask。

最後印 3。

再混入 setTimeout

async function demo() {
  console.log('A')

  await Promise.resolve()

  console.log('B')
}

setTimeout(() => {
  console.log('C')
}, 0)

demo()

console.log('D')

順序通常是:

A
D
B
C

因為:

A、D
→ 目前同步程式

B
→ await 後續的 Promise / Microtask

C
→ 下一個 Task

Day 28、29、30 在這裡就接起來了。

先別往下看,猜一次

async function run() {
  console.log('1')

  await Promise.resolve()

  console.log('2')

  setTimeout(() => {
    console.log('3')
  }, 0)

  await Promise.resolve()

  console.log('4')
}

console.log('A')

run()

console.log('B')

順序是什麼?

A
1
B
2
4
3

先跑目前同步程式:

A
1
B

第一個 await 的後續成為 Microtask,所以接著印 2。

2 執行時建立 setTimeout,callback 會等下一個 Task。

接著又遇到 await,4 的後續成為新的 Microtask。

Microtask 還沒清空,所以先印 4。

最後才輪到 timeout 的 3。

把 Day 28~30 收成一張圖

JavaScript 開始執行一個 Task
        ↓
同步程式進入 Call Stack
        ↓
setTimeout / fetch 等等待工作
可以交給 Runtime
        ↓
Promise 完成後
相關 continuation 進 Microtask
        ↓
async function 遇到 await
暫停自己的後半段
        ↓
目前同步工作結束
        ↓
清 Microtask Queue
        ↓
async function 可能在這裡恢復
        ↓
下一個 Task
        ↓
setTimeout callback 等工作再進來

現在再看到:

await fetch(...)

我不會再把它理解成:

JavaScript 停住等網路。

而會理解成:

這個 async function 先停在這裡,等 Promise 完成;主執行緒可以先去做其他事,之後再透過排程回來繼續。

如果今天只記得一件事

await 暫停的是目前 async function 的後續流程,不是把整個 JavaScript 主執行緒凍住。async/await 是 Promise 控制流程的語法抽象,Promise 完成後,函式後半段才在之後的排程點繼續。

所以現在看到非同步程式,我會問:

現在同步跑到哪裡?
↓
哪個工作交給 Runtime?
↓
哪個 Promise 還沒完成?
↓
await 暫停的是哪一個 async function?
↓
恢復時進的是 Microtask 還是下一個 Task?

30 天後,我終於不是只會照著打

Day 01 一開始只是:

npm run dev

老師叫我打,我就打。

一路往下追,才發現一個看起來很普通的前端專案,後面其實接著很多層:

npm
↓
package.json
↓
node_modules
↓
Vite
↓
App.jsx
↓
Component
↓
Array / Object / props
↓
state
↓
function
↓
scope
↓
hoisting
↓
closure
↓
this
↓
Call Stack
↓
Event Loop
↓
Promise
↓
Microtask
↓
async / await

完。


上一篇
Day 29|Promise 和 setTimeout 都是「晚點執行」,為什麼 Promise 常常先跑?
系列文
JavaScript為什麼筆記本 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言